昨天,我們完成了 Google 第三方登入的手動實作 ~
今天,想來學習如何使用 Spring Security 的 oauth2-client 套件來達成相同的目標,順便透過這次的重構,體驗一下兩種作法的差異:)!
今天主要想透過 spring security 的 OAuth2 Client 模組,讓應用程式能以簡單的流程實現第三方登入功能。這個套件的核心價值,就是將繁瑣的 OAuth 2.0 標準流程自動化。啟用後,它會在背後為我們處理好以下流程:
/oauth2/authorization/google
,前端只需要導向這個連結,即可讓使用者進入第三方登入的驗證流程。code
,這個預設的 url 為/login/oauth2/code/google
。code
和 client_secret
,在後端向 Google 交換 Access Token
和 ID Token
,並自動完成 ID Token
的所有驗證工作。Authentication
物件:在取得並驗證完使用者資訊後,它會將這些資訊打包成一個標準的 OAuth2AuthenticationToken
物件,並自動將其放入 SecurityContextHolder
中,完成身份驗證。上述的流程主要由以下幾個 Spring Security 的核心元件協作完成:
元件 | 作用 |
---|---|
ClientRegistration |
該物件會根據 application.properties 替每個OAuth 2.0 服務商(如 google) 建立的所有設定資訊,例如 client-id , scope , 以及各種端點 URL。 |
OAuth2AuthorizationRequestRedirectFilter |
負責監聽觸發登入的 URL(/oauth2/authorization/{registrationId} ),並將使用者安全地重新導向到Google的授權頁面。 |
OAuth2LoginAuthenticationFilter |
負責監聽 Redirec URL(/login/oauth2/code/{registrationId} ),處理 code 的交換、Token 的驗證,並在成功後建立 Authentication 物件。 |
OAuth2User |
該介面用來標準化從第三方登入成功後取得的使用者屬性(Attributes),無論來源是 Google、GitHub 還是 Facebook。 |
根據上述對於 Spring Security OAuth2.0 的描述,可以預期我們的流程跟一開始自行實作會有很大的不同。
框架在預設的情況下,後端幫我們處理好驗證流程後,最後會將瀏覽器導向最初訪問的頁面並夾帶 SessionId 的資訊。
但因為我們希望整個系統保持無狀態的架構,並且希望登入時可以回傳使用者資訊(像昨天的實作),因此進行了一些調整,整理流程如下:
/oauth2/authorization/{registrationId}
。OAuth2AuthorizationRequestRedirectFilter
攔截請求,自動組合好包含 client_id
, redirect_uri
, scope
, state
等參數的完整 Google 授權 URL,並重新導向使用者的瀏覽器前往 Google。GET /login/oauth2/code/google
,並在 URL 中附上一次性的 code
。OAuth2LoginAuthenticationFilter
攔截這個請求,取出 code
,並在後端與 Google 的 Token 端點交換 id_token
等憑證。id_token
,並建立一個 OAuth2AuthenticationToken
物件。HttpSession
:並將這個 Authentication
物件存入 Session。Set-Cookie: JSESSIONID=...
的Header。OAuth2LoginSuccessHandler
被觸發。它的任務,是將使用者重新導向前端callback
頁面 。GET /api/auth/oauth2/success
): 前端的 CallbackComponent
載入後,立刻帶著JSESSIONID
向後端的一個新 API 端點發起 GET
請求。JSESSIONID
Cookie 找到了對應的 Session,並還原出 OAuth2AuthenticationToken
。AuthController
,@AuthenticationPrincipal OAuth2User
被成功注入。執行「尋找或建立使用者」的邏輯,並簽發我們自家應用程式的 Access Token & Refresh Token。HttpSession
LoginResponse
物件。雖然流程看起來比最初自行實作還冗長,但事實上大部分內容都由框架替我們完成(第3點 ~ 第7點)。
我們今天主要要做的,除了啟用框架的第三方登入功能外,還要自訂身分驗證成功後的流程,包含用戶建檔、簽發 tokens,及回傳統一的登入回應(第8點~第10點)。
欲使用 spring security 的 oauth2-client ,需先匯入模組:
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
...
如同第一節模組的介紹,Oauth2Login 功能有預設的 Redirect Url,如果使用不同於申請憑證時的 Redirect Url ,會造成驗證請求失敗。
因此按照 Day 23 的流程,新增一組 Google OAuth2.0 憑證,並將 redirect_url 設定為:http://localhost:8081/login/oauth2/code/google
(注意因為要將伺服器位置換成後端資訊)。已授權的 JavaScript 來源欄位則保持前端url資訊(即 http://localhost:4200
)。
在登入頁中的「以 Google 進行登入」,是呼叫Auth Service的 loginWithGoogle
方法。因此將該方法內容改成直接將瀏覽器導向到後端由 Spring Security 自動產生的端點:
export class AuthService {
...
loginWithGoogle(): void {
window.location.href = `${environment.apiBaseUrl}/oauth2/authorization/google`;
}
}
以下使用官方指定的變數名稱 spring.security.oauth2.client.registration.google.<properties>
,讓套件自動偵測這些設定內容:
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=openid,profile,email
其他預設的變數名稱可以參考此文件。
(本專案透過 docker-compose 啟動服務,傳入變數內容值,因此跟昨天的實作一樣,會到 .env
與 docker-compose.yml
中新增對應內容。)
這邊要加上兩個設定:(1) oauth2Login 功能啟用 (2) 開放框架預設的兩個 url(負責導向 google 授權頁面的 url 與 redirect url )未經驗證即可拜訪的權限:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
...
// 加入這次需要公開訪問的路徑
.requestMatchers(
"/oauth2/**", // OAuth2 登入觸發點 (如 /oauth2/authorization/google)
"/login/oauth2/code/*",// OAuth2 登入成功後的 redirect url
).permitAll()
.anyRequest().authenticated()
)
// 加入這項設定啟用 Oauth2.0 Login 功能
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2LoginSuccessHandler) // 加入自訂的Handler
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
另外,重導向時瀏覽器可能自動請求 favicon.ico 或 .well-known 相關資源,可以自訂 WebSecurityCustomizer Bean 來開放這些靜態資源的請求權限,或一樣在此暫時 permitAll 即可。
建立一個 OAuth2LoginSuccessHandler
,並繼承 AuthenticationSuccessHandler
,透過覆寫其 onAuthenticationSuccess
方法來自訂驗證成功後的動作,這邊僅簡單讓瀏覽器重新導向 我們在前端的 Callback 頁面:
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String frontendRedirectUrl = "http://localhost:4200/auth/callback"; // 前端接收回調的頁面
// 執行重新導向
response.sendRedirect(frontendRedirectUrl);
}
}
之所以要導回前端 Callback 頁面是因為我希望驗證成功、登入後,可以取得跟以帳號密碼登入時相同的回應(即包含我們自行簽發的 tokens 跟使用者資訊的回應)。
但框架的預設行為是驗證成功後,會將使用者導回當初訪問的頁面,並在 Cookie 附上 SessionId。如果希望附上其他資訊,可能必須要帶在URL中,較不安全。
因此,這邊想到的方法是先導回 Callback 頁面,由該元件發出請求,並在該請求的 Response Body 中取得登入資訊。
承上述,Callback 元件只負責做一件事,就是當使用者被導向 Callback 這個頁面時,發出取得登入資訊的請求:
export class CallbackComponent implements OnInit {
...
ngOnInit(): void {
this.authService.fetchOauthLoginResult();
}
}
到 AuthSevice
新增fetchOauthLoginResult
方法:
export class AuthService {
...
fetchOauthLoginResult(): void {
const backendApi = `${environment.apiBaseUrl}/users/oauth2/success`;
// 加入 withCredentials: true
this.http.get<UserProfile>(backendApi, { withCredentials: true }).subscribe({
next: (loginResponse) => {
//以下接收使用者資訊的後續處理與先前相同
this.currentUserSubject.next(loginResponse);
localStorage.setItem('currentUser', JSON.stringify(loginResponse));
this.router.navigate(['/home']);
},
error: (err) => {
console.error('從後端獲取 Token 失敗:', err);
this.router.navigate(['/auth/login']);
}
});
}
}
在這個方法中,我們設定withCredentials: true
,瀏覽器會自動將先前由後端設定的 Session Cookie (JSESSIONID) 附加到這個請求上。 後端會根據 SessionId 解析使用者身分,驗證後,該端點會回傳標準的登入資訊 (tokens 與 使用者資訊),接收資訊後的處理方式同與先前實作無異。
接著說明 oauth2/success
這個對應的端點。這個端點專門給前端在上述身分驗證流程成功後,取得登入資訊使用:
public class AuthController {
...
@GetMapping("/oauth2/success")
public ResponseEntity<?> getOauth2LoginSuccessInfo(@AuthenticationPrincipal OAuth2User oauth2User, HttpServletRequest request) {
if (oauth2User == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User not authenticated via OAuth2 session.");
}
LoginResponse loginResponse = googleAuthService.getOauth2LoginSuccessInfo(oauth2User);
// 註銷 sessionId、清除 session 內容
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
return ResponseEntity.ok(loginResponse);
}
}
因為本身希望這個系統是無狀態的,因此完成到這一步,把 JWT 給前端後,就會將本次請求的 sessionId 註銷,未來請求應該帶上 JWT 而非透過 session 來驗證身分。
新增一個 getOauth2LoginSuccessInfo
的方法在 GoogleAuthService
中,這個方法主要負責兩件事,分別是:(1) 使用者查詢與建檔 (2)產生我們登入所需的資訊 (tokens 與使用者資訊),實作內容如下:
public LoginResponse getOauth2LoginSuccessInfo( OAuth2User oauth2User) {
// 尋找或建立使用者
String email = oauth2User.getAttribute("email");
String googleId = oauth2User.getName();
String name = oauth2User.getAttribute("name");
String pictureUrl = oauth2User.getAttribute("picture");
UserEntity user = findOrCreateUser(googleId, email, name, pictureUrl);
// 簽發 Access Token 和 Refresh Token
String accessToken = jwtUtils.generateJwtToken(user);
RefreshTokenEntity refreshToken = refreshTokenService.createRefreshToken(user);
// 回傳 LoginResponse 物件
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList();
LoginResponse loginResponse = new LoginResponse(
accessToken,
refreshToken.getToken(),
user.getId(),
user.getName(),
user.getEmail(),
user.getPictureUrl(),
roles
);
return loginResponse;
}
今天,我們使用了 spring security 的 OAuth2 Client 模組來實作 Google 第三登入的功能,為了滿足框架沒有替我們完成的的其他需求(使用者建檔、簽發 JWT、回傳逼準登入資訊),自訂了驗證成功後的流程。
比較特別的是,我們透過 Callbcack 這個過渡頁,暫時以 SeesionId 來驗證身分,取得標準登入資訊的流程。會這樣做是因為框架的預設行為,在驗證成功後會直接將瀏覽器導回某個頁面。如果我們想取得像先前實作中的登入資訊,放在 QueryString 中會有安全性的問題,好像比較難以這種方式取得我們期望的標準,所以才繞了這樣一圈。
(當然,如果有更好的做法,或這麼做會造成什麼不良影響,都歡迎提出建議,感激不盡!!)
總而言之,這個框架最大的好處就是將繁瑣的 OAuth 2.0 流程自動化,為我們處理了從請求授權到交換權杖的所有標準步驟。這讓我們能省去重複的樣板程式碼,更專注於實現應用程式自身的核心業務邏輯。